Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 13a95bed4a2265a75a3bc2f752a3f0fc34ed9137


Parents : 9d6f9a1
Author : Mark Qvist <mark@unsigned.io>
Date : 2025-11-27T00:55:47+01:00

Improved file recording

Changes

4 files changed, 126 insertions(+), 107 deletions(-)

M LXST/Sinks.py +46 -18

Diff

diff --git a/LXST/Codecs/Opus.py b/LXST/Codecs/Opus.py
index f85defc..96a85ea 100644
--- a/LXST/Codecs/Opus.py
+++ b/LXST/Codecs/Opus.py
@@ -40,85 +40,73 @@ class Opus(Codec):
self.output_bitrate = 0
self.set_profile(profile)
+ @staticmethod
+ def profile_channels(profile):
+ if profile == Opus.PROFILE_VOICE_LOW: return 1
+ elif profile == Opus.PROFILE_VOICE_MEDIUM: return 1
+ elif profile == Opus.PROFILE_VOICE_HIGH: return 1
+ elif profile == Opus.PROFILE_VOICE_MAX: return 2
+ elif profile == Opus.PROFILE_AUDIO_MIN: return 1
+ elif profile == Opus.PROFILE_AUDIO_LOW: return 1
+ elif profile == Opus.PROFILE_AUDIO_MEDIUM: return 2
+ elif profile == Opus.PROFILE_AUDIO_HIGH: return 2
+ elif profile == Opus.PROFILE_AUDIO_MAX: return 2
+ else: raise CodecError(f"Unsupported profile")
+
+ @staticmethod
+ def profile_samplerate(profile):
+ if profile == Opus.PROFILE_VOICE_LOW: return 8000
+ elif profile == Opus.PROFILE_VOICE_MEDIUM: return 24000
+ elif profile == Opus.PROFILE_VOICE_HIGH: return 48000
+ elif profile == Opus.PROFILE_VOICE_MAX: return 48000
+ elif profile == Opus.PROFILE_AUDIO_MIN: return 8000
+ elif profile == Opus.PROFILE_AUDIO_LOW: return 12000
+ elif profile == Opus.PROFILE_AUDIO_MEDIUM: return 24000
+ elif profile == Opus.PROFILE_AUDIO_HIGH: return 48000
+ elif profile == Opus.PROFILE_AUDIO_MAX: return 48000
+ else: raise CodecError(f"Unsupported profile")
+
+ @staticmethod
+ def profile_application(profile):
+ if profile == Opus.PROFILE_VOICE_LOW: return "voip"
+ elif profile == Opus.PROFILE_VOICE_MEDIUM: return "voip"
+ elif profile == Opus.PROFILE_VOICE_HIGH: return "voip"
+ elif profile == Opus.PROFILE_VOICE_MAX: return "voip"
+ elif profile == Opus.PROFILE_AUDIO_MIN: return "audio"
+ elif profile == Opus.PROFILE_AUDIO_LOW: return "audio"
+ elif profile == Opus.PROFILE_AUDIO_MEDIUM: return "audio"
+ elif profile == Opus.PROFILE_AUDIO_HIGH: return "audio"
+ elif profile == Opus.PROFILE_AUDIO_MAX: return "audio"
+ else: raise CodecError(f"Unsupported profile")
+
+ @staticmethod
+ def profile_bitrate_ceiling(profile):
+ if profile == Opus.PROFILE_VOICE_LOW: return 6000
+ elif profile == Opus.PROFILE_VOICE_MEDIUM: return 8000
+ elif profile == Opus.PROFILE_VOICE_HIGH: return 16000
+ elif profile == Opus.PROFILE_VOICE_MAX: return 32000
+ elif profile == Opus.PROFILE_AUDIO_MIN: return 8000
+ elif profile == Opus.PROFILE_AUDIO_LOW: return 14000
+ elif profile == Opus.PROFILE_AUDIO_MEDIUM: return 28000
+ elif profile == Opus.PROFILE_AUDIO_HIGH: return 56000
+ elif profile == Opus.PROFILE_AUDIO_MAX: return 128000
+ else: raise CodecError(f"Unsupported profile")
+
+ @staticmethod
+ def max_bytes_per_frame(bitrate_ceiling, frame_duration_ms):
+ return math.ceil((bitrate_ceiling/8)*(frame_duration_ms/1000))
+
def set_profile(self, profile):
- if profile == self.PROFILE_VOICE_LOW:
- self.profile = profile
- self.channels = 1
- self.input_channels = self.channels
- self.output_samplerate = 8000
- self.opus_encoder.set_application("voip")
- elif profile == self.PROFILE_VOICE_MEDIUM:
- self.profile = profile
- self.channels = 1
- self.input_channels = self.channels
- self.output_samplerate = 24000
- self.opus_encoder.set_application("voip")
- elif profile == self.PROFILE_VOICE_HIGH:
- self.profile = profile
- self.channels = 1
- self.input_channels = self.channels
- self.output_samplerate = 48000
- self.opus_encoder.set_application("voip")
- elif profile == self.PROFILE_VOICE_MAX:
- self.profile = profile
- self.channels = 2
- self.input_channels = self.channels
- self.output_samplerate = 48000
- self.opus_encoder.set_application("voip")
- elif profile == self.PROFILE_AUDIO_MIN:
- self.profile = profile
- self.channels = 1
- self.input_channels = self.channels
- self.output_samplerate = 8000
- self.opus_encoder.set_application("audio")
- elif profile == self.PROFILE_AUDIO_LOW:
- self.profile = profile
- self.channels = 1
- self.input_channels = self.channels
- self.output_samplerate = 12000
- self.opus_encoder.set_application("audio")
- elif profile == self.PROFILE_AUDIO_MEDIUM:
- self.profile = profile
- self.channels = 2
- self.input_channels = self.channels
- self.output_samplerate = 24000
- self.opus_encoder.set_application("audio")
- elif profile == self.PROFILE_AUDIO_HIGH:
- self.profile = profile
- self.channels = 2
- self.input_channels = self.channels
- self.output_samplerate = 48000
- self.opus_encoder.set_application("audio")
- elif profile == self.PROFILE_AUDIO_MAX:
- self.profile = profile
- self.channels = 2
- self.input_channels = self.channels
- self.output_samplerate = 48000
- self.opus_encoder.set_application("audio")
- else:
- raise CodecError(f"Unsupported profile configured for {self}")
+ self.channels = self.profile_channels(profile)
+ self.input_channels = self.channels
+ self.output_samplerate = self.profile_samplerate(profile)
+ self.opus_encoder.set_application(self.profile_application(profile))
+ self.profile = profile
def update_bitrate(self, frame_duration_ms):
- if self.profile == self.PROFILE_VOICE_LOW:
- self.bitrate_ceiling = 6000
- elif self.profile == self.PROFILE_VOICE_MEDIUM:
- self.bitrate_ceiling = 8000
- elif self.profile == self.PROFILE_VOICE_HIGH:
- self.bitrate_ceiling = 16000
- elif self.profile == self.PROFILE_VOICE_MAX:
- self.bitrate_ceiling = 32000
- elif self.profile == self.PROFILE_AUDIO_MIN:
- self.bitrate_ceiling = 8000
- elif self.profile == self.PROFILE_AUDIO_LOW:
- self.bitrate_ceiling = 14000
- elif self.profile == self.PROFILE_AUDIO_MEDIUM:
- self.bitrate_ceiling = 28000
- elif self.profile == self.PROFILE_AUDIO_HIGH:
- self.bitrate_ceiling = 56000
- elif self.profile == self.PROFILE_AUDIO_MAX:
- self.bitrate_ceiling = 128000
-
- max_bytes_per_frame = math.ceil((self.bitrate_ceiling/8)*(frame_duration_ms/1000))
+ self.bitrate_ceiling = self.profile_bitrate_ceiling(self.profile)
+ max_bytes_per_frame = self.max_bytes_per_frame(self.bitrate_ceiling, frame_duration_ms)
+
configured_bitrate = (max_bytes_per_frame*8)/(frame_duration_ms/1000)
self.opus_encoder.set_max_bytes_per_frame(max_bytes_per_frame)

diff --git a/LXST/Primitives/Recorders.py b/LXST/Primitives/Recorders.py
index 9809251..42aea82 100644
--- a/LXST/Primitives/Recorders.py
+++ b/LXST/Primitives/Recorders.py
@@ -11,11 +11,8 @@ class FileRecorder():
self._record_device = device
self.__profile = profile
self.__source = None
- self.__sink = OpusFileSink(path=self._file_path)
- self.__raw = LXST.Codecs.Raw()
- self.__loopback = LXST.Sources.Loopback()
- self.__output_pipeline = LXST.Pipeline(source=self.__loopback, codec=self.__raw, sink=self.__sink)
- self.__input_pipeline = None
+ self.__sink = OpusFileSink(path=self._file_path, profile=profile)
+ self.__null = LXST.Codecs.Null()
self.set_source(device)
@property
@@ -28,8 +25,8 @@ class FileRecorder():
def set_source(self, device=None):
self._record_device = device
- self.__source = LineSource(preferred_device=self._record_device, target_frame_ms=20, codec=self.__raw, sink=self.__loopback)
- self.__input_pipeline = LXST.Pipeline(source=self.__source, codec=self.__raw, sink=self.__loopback)
+ self.__source = LineSource(preferred_device=self._record_device, target_frame_ms=60, codec=self.__null, sink=self.__sink)
+ self.__sink.source = self.__source
def set_output_path(self, path):
self._file_path = path
@@ -37,13 +34,12 @@ class FileRecorder():
def start(self):
if self.__source:
- self.__input_pipeline.start()
- self.__output_pipeline.start()
+ self.__source.start()
def stop(self):
if self.__source:
- self.__input_pipeline.stop()
- self.__output_pipeline.stop()
+ self.__source.stop()
+ while self.__sink.frames_waiting: time.sleep(0.1)
self.__sink.stop()
def record(self):

diff --git a/LXST/Sinks.py b/LXST/Sinks.py
index 2b5fd47..06bd364 100644
--- a/LXST/Sinks.py
+++ b/LXST/Sinks.py
@@ -5,6 +5,7 @@ import threading
import numpy as np
from collections import deque
from LXST.Codecs import Opus
+from LXST.Codecs.Codec import resample_bytes, resample
class LinuxBackend():
SAMPLERATE = 48000
@@ -222,7 +223,7 @@ class OpusFileSink(LocalSink):
FINALIZE_TIMEOUT = 2
TYPE_MAP_FACTOR = np.iinfo("int16").max
- def __init__(self, path=None, autodigest=True):
+ def __init__(self, path=None, autodigest=True, profile=Opus.PROFILE_AUDIO_MAX):
self.should_run = False
self.digest_thread = None
self.digest_lock = threading.Lock()
@@ -231,18 +232,29 @@ class OpusFileSink(LocalSink):
self.autodigest = autodigest
self.autostart_min = self.AUTOSTART_MIN
self.buffer_max_height = self.MAX_FRAMES
+ self.profile = profile
+ self.bitdepth = 32
self.samplerate = None
- self.channels = None
+ self.output_samplerate = Opus.profile_samplerate(self.profile)
+ self.channels = Opus.profile_channels(self.profile)
+ self.application = Opus.profile_application(self.profile)
+ self.bitrate_ceiling = Opus.profile_bitrate_ceiling(self.profile)
+ self.max_bytes_per_frame = None
self.samples_per_frame = None
self.frame_time = None
self.output_latency = 0
self.max_latency = 0
self.underrun_at = None
+ self.samples_written = 0
self.__recording_stopped = False
+ self.__finalized = False
self.__opus_writer = None
self.__encoder = None
self.__output_path = path
+ @property
+ def frames_waiting(self): return len(self.frame_deque)
+
def can_receive(self, from_source=None):
with self.insert_lock:
if self.__recording_stopped: return False
@@ -269,35 +281,43 @@ class OpusFileSink(LocalSink):
self.digest_thread.start()
def stop(self):
- self.__recording_stopped = True
- timeout = time.time()+self.FINALIZE_TIMEOUT
- while len(self.frame_deque) > 0 and time.time() < timeout: time.sleep(0.05)
- self.should_run = False
- if self.__opus_writer:
+ if self.should_run:
+ self.__recording_stopped = True
+ timeout = time.time()+self.FINALIZE_TIMEOUT
+ while len(self.frame_deque) > 0 and time.time() < timeout: time.sleep(0.05)
+ self.should_run = False
+ while self.__finalized == False: time.sleep(0.05)
self.__opus_writer.close()
self.__opus_writer = None
def __digest_job(self):
with self.digest_lock:
- from .Codecs.libs.pyogg.opus_buffered_encoder import OpusBufferedEncoder
- from .Codecs.libs.pyogg.ogg_opus_writer import OggOpusWriter
+ from .Codecs.libs.pyogg import OpusBufferedEncoder
+ from .Codecs.libs.pyogg import OggOpusWriter
if not self.__output_path: raise ValueError("No recording file path configured")
+ self.max_bytes_per_frame = Opus.max_bytes_per_frame(self.bitrate_ceiling, self.frame_time*1000)
self.__encoder = OpusBufferedEncoder()
- self.__encoder.set_application("audio")
+ self.__encoder.set_application(self.application)
self.__encoder.set_sampling_frequency(self.samplerate)
self.__encoder.set_channels(self.channels)
self.__encoder.set_frame_size(int(self.frame_time*1000))
+ self.__encoder.set_max_bytes_per_frame(self.max_bytes_per_frame)
self.__opus_writer = OggOpusWriter(self.__output_path, self.__encoder)
-
- while self.should_run and self.__opus_writer:
- frames_ready = len(self.frame_deque)
+
+ final_silence_frames = 10
+ while self.should_run or final_silence_frames > 0:
+ frames_ready = len(self.frame_deque) or (self.should_run == False and final_silence_frames)
if frames_ready:
self.output_latency = len(self.frame_deque)*self.frame_time
self.max_latency = self.buffer_max_height*self.frame_time
self.underrun_at = None
- with self.insert_lock: frame = self.frame_deque.popleft()
+ if self.should_run:
+ with self.insert_lock: frame = self.frame_deque.popleft()
+ else:
+ final_silence_frames -= 1
+ frame = np.zeros((self.samples_per_frame, self.channels), dtype="float32")
if frame.shape[1] > self.channels: frame = frame[:, 0:self.channels]
elif frame.shape[1] < self.channels:
@@ -309,11 +329,19 @@ class OpusFileSink(LocalSink):
silence_frame = np.zeros((self.samples_per_frame-frame.shape[0], frame.shape[1]), dtype=frame.dtype)
frame = np.vstack([frame, silence_frame])
- self.__opus_writer.write(memoryview(bytearray((frame*self.TYPE_MAP_FACTOR).astype(np.int16).tobytes())))
+ self.samples_written += frame.shape[0]
+
+ if self.samplerate != 48000:
+ frame = resample(frame, self.bitdepth, self.channels, self.samplerate, self.output_samplerate)
+
+ input_samples = frame*self.TYPE_MAP_FACTOR
+ input_samples = input_samples.astype(np.int16)
+
+ self.__opus_writer.write(bytearray(input_samples.tobytes()))
- if len(self.frame_deque) > self.buffer_max_height:
- RNS.log(f"Buffer lag on {self} (height {len(self.frame_deque)}), dropping one frame", RNS.LOG_DEBUG)
- self.frame_deque.popleft()
+ if len(self.frame_deque) > self.buffer_max_height: RNS.log(f"Buffer lag on {self} (height {len(self.frame_deque)})", RNS.LOG_DEBUG)
else:
if self.underrun_at == None: self.underrun_at = time.time()
else: time.sleep(self.frame_time*0.1)
+
+ self.__finalized = True

diff --git a/examples/filerecorder.py b/examples/filerecorder.py
index 9c41efb..e4b4c4b 100644
--- a/examples/filerecorder.py
+++ b/examples/filerecorder.py
@@ -5,7 +5,13 @@ from LXST.Primitives.Recorders import FileRecorder
filename = "recording.opus"
+# With default profile (maximum quality)
recorder = FileRecorder(filename)
+
+# Or, with specific profile
+# from LXST.Codecs import Opus
+# recorder = FileRecorder(filename, profile=Opus.PROFILE_VOICE_MEDIUM)
+
recorder.start()
print("Recording started")
@@ -13,5 +19,6 @@ try: input()
except KeyboardInterrupt: pass
recorder.stop()
-time.sleep(0.5)
-print(f"Recording saved to {filename}")
\ No newline at end of file
+print(f"Recording saved to {filename}")
+
+time.sleep(0.2)
\ No newline at end of file


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────